01.5 精通自定义 View 之绘图基础——Canvas

返回自定义 View 目录

1.5.1 Canvas 变换

1. 平移 (Translate)

画布的原始状态是以左上角为原点,向右是 X 轴正方向,向下是 Y 轴正方向。

1
void translate(float dx, float dy)

参数:
float dx:水平方向平移的距离,正数为向正方向 (向右) 平移的量,负数为向负方向 (向左) 平移的量。
float dy:垂直方向平移的距离,正数为向正方向 (向下) 平移的量,负数为向负方向 (向上) 平移的量。

示例:

1
2
3
4
5
6
7
8
paintGreen = generatePaint(Color.GREEN, Paint.Style.STROKE, 3);
paintRed = generatePaint(Color.RED, Paint.Style.STROKE, 3);
rect = new Rect(20,20,400,220);
canvas.drawRect(rect, paintGreen);
// 画布平移
canvas.translate(100, 100);
canvas.drawRect(rect, paintRed);

上述示例中,调用 canvas.translate(100, 100) 后,绿色矩形区域并没有随着画布移动。这是由于屏幕显示与 Canvas 根本不是一个概念!

2. 屏幕显示与 Canvas 的关系

Canvas 是一个很虚幻的概念,相当于一个透明图层。每次在 Canvas 上画图时 (调用 drawXXX 系列函数),都会先产生一个透明图层,然后在这个图层上画图,画完之后覆盖在屏幕上显示。所以,上述结果是经以下几个步骤形成的:
(1) 在调用 canvas.drawRect(rect1, paint_green) 时,产生一个 Canvas 透明图层,由于当时还没有对坐标系进行平移,所以坐标原点是 (0,0);在 Canvas 上画好之后,覆盖到屏幕上显示出来。过程如下图所示。

(2) 在调用 canvas.drawRect(rect1, paint_red) 时,又会产生一个全新的 Canvas 透明图层,但此时画布坐标已经改变了,即分别向右和向下移动了 100 像素,所以此时的绘图方式如下图所示 (合成视图,从上往下看的合成方式)。

总结:

  • 当每次调用 drawXXX 系列函数来绘图时,都会产生一个全新的 Canvas 透明图层。
  • 如果在调用 drawXXX 系列函数前,调用平移、旋转等函数对 Canvas 进行了操作,那么这个操作是不可逆的。每次产生的画布的最新位置都是这些操作后的位置。
  • 在 Canvas 图层与屏幕合成时,超出屏幕范围的图像是不会显示出来的。

3. 旋转 (Rotate)

画布的旋转默认是围绕坐标原点来进行的。这里容易产生错觉,看起来是图片旋转了,其实我们旋转的是画布,以后在此画布上绘制的图形显示出来的时候看起来都是旋转的。

1
2
void rotate(float degrees)
void rotate(float degrees, float px, float py)

参数:

  • float degrees:旋转的度数,正数指顺时针旋转。旋转中心点是 (0,0)。
  • float px, py:指定旋转的中心点坐标 (px,py)。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public TestView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
mPaint = new Paint();
mPaint.setColor(Color.BLUE);
mPaint.setStyle(Paint.Style.STROKE);
mRect = new RectF(300,50,500,150);
}
@Override
protected void onDraw(Canvas canvas) {
canvas.drawRect(mRect, mPaint);
mPaint.setColor(Color.RED);
mPaint.setStyle(Paint.Style.FILL);
canvas.rotate(30);
canvas.drawRect(mRect, mPaint);
}

旋转

旋转原理:
第一次合成:canvas.drawRect(mRect, mPaint)

4. 缩放 (Scale)

变更坐标轴密度。

1
public void scale(float sx, float sy)

参数:
float sx:水平方向伸缩的比例。小数表示缩小,整数表示放大。
float sy:垂直方向伸缩的比例。小数表示缩小,整数表示放大。

示例:

1
2
3
4
5
6
7
mPaintBlue = generatePaint(Color.BLUE, Paint.Style.STROKE, 5);
mPaintRed = generatePaint(Color.RED, Paint.Style.STROKE, 5);
mRect = new RectF(100,50,500,150);
canvas.drawRect(mRect, mPaintBlue);
canvas.scale(0.5f, 1);
canvas.drawRect(mRect, mPaintRed);

蓝框是原坐标轴密度图形,红框是 X 轴密度缩小到 0.5 倍之后显示的图形。

5. 扭曲 (Skew)

1
void skew(float sx, float sy)

参数:
float sx:将画布在 X 轴方向上倾斜相应的角度,sx 为倾斜角度的正切值。
float sy:将画布在 Y 轴方向上倾斜相应的角度,sy 为倾斜角度的正切值。

注意:这里都是倾斜角度的正切值。比如,在 X 轴方向上倾斜 60°,tan60=1.732。

示例:

1
2
3
4
5
mRect = new RectF(100,100,300,300);
canvas.drawRect(mRect, mPaintBlue);
canvas.skew(0.5f, 0.5f);
canvas.drawRect(mRect, mPaintRed);

6. 裁剪画布 (clip 系列函数)

裁剪画布是指利用 clip 系列函数,通过与 Rect、Path、Region 取交、并、差等集合运算来获得最新的画布形状。除调用 save()、restore() 函数以外,这个操作是不可逆的,一旦 Canvas 被裁剪,就不能恢复。

在使用裁剪画布系列函数时,需要禁用硬件加速功能。
setLayerType(LAYER_TYPE_SOFTWARE, null);

1
2
3
4
5
6
7
8
9
10
11
12
13
14
boolean clipPath(Path path)
boolean clipPath(Path path, Region.Op op)
boolean clipRect(RectF rect)
boolean clipRect(Rect rect)
boolean clipRect(Rect rect, Region.Op op)
boolean clipRect(RectF rect, Region.Op op)
boolean clipRect(int left, int top, int right, int bottom)
boolean clipRect(float left, float top, float right, float bottom)
boolean clipRect(float left, float top, float right, float bottom,
Region.Op op)
boolean clipRegion(Region region)
boolean clipRegion(Region region, Region.Op op)

示例:

1
2
3
canvas.drawColor(Color.RED);
canvas.clipRect(new Rect(100, 100, 200, 200));
canvas.drawColor(Color.GREEN);

先把背景色涂成红色,显示在屏幕上,然后裁剪画布,最后将最新的画布涂成绿色。可见,绿色部分只有一小块,而不再是整个屏幕。

1.5.2 画布的保存与恢复

1. save() 和 restore() 函数

前面介绍的所有对画布的操作都是不可逆的,如果要对画布的大小和状态 (旋转角度、扭曲等) 进行实时保存和恢复,需要借助 save() 和 restore() 这两个函数。

save():每次调用,都会先保存当前画布的状态,然后将其放入特定的栈中。
restore():每次调用,都会把栈中顶层的画布状态取出来,并按照这个状态恢复当前的画布,然后在这个画布上作画。

示例:

1
2
3
4
5
6
7
8
canvas.drawColor(Color.RED);
// 保存当前画布大小,即整屏
canvas.save();
canvas.clipRect(new Rect(100, 100, 800, 800));
canvas.drawColor(Color.GREEN);
// 恢复整屏画布
canvas.restore();
canvas.drawColor(Color.BLUE);

2. restoreToCount(int saveCount) 函数

我们可以多次调用 save() 函数,但每次调用 restore() 函数,只会将顶层的画布状态出栈。有时可能只需要用到特定的画布,这就需要多次 出栈。为了解决这个问题,Google 提供了另一个出栈函数 restoreToCount(int saveCount)。

1
public int save();

在利用 save() 函数保存画布时,会有一个 int 类型的返回值。该返回值是当前所保存的画布所在栈的索引。

1
public void restoreToCount(int saveCount);

而 restoreToCount() 函数的用法就是一直出栈,直到指定索引的画布出栈为止,即将指定索引的画布作为当前画布。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
canvas.drawColor(Color.RED);
// 保存的画布大小为全屏幕大小
int c1 = canvas.save();
canvas.clipRect(new Rect(100, 100, 800, 800));
canvas.drawColor(Color.GREEN);
// 保存的画布大小为Rect(100, 100, 800, 800)
int c2 = canvas.save();
canvas.clipRect(new Rect(200, 200, 700, 700));
canvas.drawColor(Color.BLUE);
// 保存的画布大小为Rect(200, 200, 700, 700)
int c3 = canvas.save();
canvas.clipRect(new Rect(300, 300, 600, 600));
canvas.drawColor(Color.BLACK);
// 保存的画布大小为Rect(300, 300, 600, 600)
int c4 = canvas.save();
canvas.clipRect(new Rect(400, 400, 500, 500));
canvas.drawColor(Color.WHITE);
// 连续三次出栈,将最后一次出栈的画布状态
// 作为当前画布,并填充为黄色
// canvas.restore();
// canvas.restore();
// canvas.restore();
canvas.restoreToCount(c2);
canvas.drawColor(Color.YELLOW);
}

1.5.3 示例一:圆形头像

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class TestView extends View {
private Paint mPaint;
private Path mPath;
private Bitmap mBmp;
public TestView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
init();
}
private void init() {
// 禁用硬件加速
// setLayerType(LAYER_TYPE_SOFTWARE, null);
mPaint = new Paint();
mPath = new Path();
mBmp = BitmapFactory.decodeResource(getResources(), R.drawable.image);
int x = mBmp.getWidth() / 2;
int y = mBmp.getHeight() / 2;
int r = Math.min(x, y);
mPath.addCircle(x, y, r, Path.Direction.CCW);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
canvas.save();
canvas.clipPath(mPath);
canvas.drawBitmap(mBmp, 0, 0, mPaint);
canvas.restore();
}
}

前面说过,在使用 clip 系列函数时,要禁用硬件加速功能。 setLayerType(LAYER_TYPE_SOFTWARE,null);
然而在手机型号 Pixel XL 上测试,是否禁用硬件加速功能并不影响 clip 函数。

1.5.4 示例二:裁剪动画

1. 原理

动画原理就是每次将裁剪区域变大,在裁剪区域内的图像就会显示出来,而裁剪区域之外的图像不会显示。而关键问题在于如何计算裁剪区域。

裁剪画布,在裁剪画布内的区域都是显示出来的,所以显示出来的区域才是裁剪区域。从图示中可以看出,有两个裁剪区域。

裁剪区域一:从左向右,逐渐变大。假设宽度是 clipWidth,高度是 CLIP_HEIGHT,那么裁剪区域一所对应的 Rect 对象如下:

1
Rect(0, 0, clipWidth, CLIP_HEIGHT);

裁剪区域二:从右向左,同样逐渐变大,它的宽度、高度都与裁剪区域一相同。但它是从右向左变化的,假设图片的宽度是 width,那么裁剪区域二所对应的 Rect 对象如下:

1
Rect(width - clipWidth, CLIP_HEIGHT, width, 2* CLIP_HEIGHT);

2. 示例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
public class TestView extends View {
private Paint mPaint;
private Bitmap mBitmap;
private int clipWidth = 0;
private int width;
private int height;
private static final int CLIP_HEIGHT = 30;
private Path mPath;
private RectF mRect;
public TestView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
init();
}
private void init() {
setLayerType(LAYER_TYPE_SOFTWARE, null);
mPaint = new Paint();
mBitmap = BitmapFactory.decodeResource(getResources(), R.drawable.bg);
width = mBitmap.getWidth();
height = mBitmap.getHeight();
mPath = new Path();
mRect = new RectF();
}
@Override
protected void onDraw(Canvas canvas) {
mPath.reset();
int i = 0;
while (i * CLIP_HEIGHT <= height) {
if (i % 2 == 0) {
mRect.set(0, i * CLIP_HEIGHT, clipWidth, (i+1) * CLIP_HEIGHT);
} else {
mRect.set(width - clipWidth, i * CLIP_HEIGHT, width, (i+1) * CLIP_HEIGHT);
}
// 替换 Region.union 方法
mPath.addRect(mRect, Path.Direction.CCW);
i++;
}
// 替换 canvas.clipRegion 方法
canvas.clipPath(mPath);
canvas.drawBitmap(mBitmap, 0, 0, mPaint);
if (clipWidth > width) {
return;
}
clipWidth += 5;
invalidate();
}
}